Conversation
* feat(ui): add @lody/ui with StyleX tokens and Base UI Button Introduce packages/ui as the Base UI + StyleX home for shared primitives. It ships colour, scale, shadow and motion tokens tuned to the two palettes the app renders (Lody Light and Vesper), squircle per-size radii, forced light/dark themes, and a Button with primary/secondary/ghost/destructive/link variants, mini..large sizes, tone, pill shape and icon layout. Wire the StyleX unplugin into the Electron renderer, site-docs and Storybook, pin one cascade layer order (theme, base, stylex, components, utilities) so Tailwind preflight sits below component styles and utilities above them, and inject the dev stylesheet into the TanStack Start root head. Replace every packages/components Button import and buttonVariants user with @lody/ui/button, delete the Radix button, and give AlertDialog action and cancel their children back with a variant prop instead of red utilities. Model: claude-fable-5-1 Claude-Session: https://claude.ai/code/session_01KJL8hsY2LscFcvxHFDzwxt * fix(ui): use button tokens for composer actions Model: gpt-6 --------- Co-authored-by: Wibus Wu <62133302+wibus-wee@users.noreply.github.com>
Rebuild the design reference from PR #305 inside the repository. The board lived at an authenticated Claude artifact URL that cannot be opened from a checkout or reviewed in a diff, so the visual system had no in-repo authority. `packages/ui/src/gallery` renders every semantic colour, elevation rung, shadow, radius, control size, type step, space step, constant and Button state from the tokens themselves, once per palette in labelled side-by-side panels. The value under each sample is read back off the rendered node with getComputedStyle, so a token that changes its value shows the new one without the board being edited. `packages/components` hosts it as the Design System / UI Gallery story on the existing Storybook; no new dependency or dev server. Building it exposed a defect in ThemeRoot: the button's colour tokens are declared once at the document root, and a custom property resolves at its declaration site, so a palette forced on a subtree left buttons carrying the root palette. Vesper rendered light secondary and icon buttons. `buttonPaletteTheme` re-declares those tokens on the element that carries the palette, and ThemeRoot and forcedThemeClassNames apply it with each palette. The gallery test walks the keys of the token objects, so a token with no sample fails the package's tests. Model: claude-opus-5 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(ui): add the @lody/ui field primitives
Only Button had migrated into @lody/ui, so every remaining control in
packages/components/src/ui still read Tailwind field concepts the new token
rules define no equivalent for: input-border, input-field, input-placeholder,
ring, muted. Those three files already disagreed with each other, carrying
three disabled treatments (bg-muted plus 60%, 70%, and the rules' 45%) and two
focus rings. Migrating Input, Textarea and Label one at a time across that gap
would have let each control invent its own state colours and fork the token
system the migration exists to unify.
Add one `field` component token group covering the label, the control, the help
text and the error, and a Base UI Field composition on top of it. Field.Root
owns name, disabled and validity; Field.Label, Input, Textarea,
Field.Description and Field.Error read that state through Base UI's className
callback instead of taking their own copies, so a caller states a field is
invalid in one place and cannot leave a control and its message disagreeing.
Input is Base UI's Input, Textarea is the same Field.Control rendered as a
textarea because Base UI has no textarea part, and Field.Label replaces
@radix-ui/react-label. src/field/well.ts holds the resting, ring and disabled
appearance the family shares so the controls that migrate next cannot each grow
their own.
The old-to-new mapping and the rest, placeholder, focus, invalid, disabled and
selected states are written into src/tokens/RULES.md, and the gallery gains the
composition, both controls, all three sizes and every state in both palettes.
ThemeRoot now applies a list of component palette themes rather than one, which
is the general mechanism the token gallery note left open until a second
colour-valued group existed.
Building this exposed that the desktop shell ends its Tailwind base layer with
`*:focus, *:focus-visible { outline: none !important }`. Layer priority is
reversed for important declarations, so no StyleX rule can win: a focused
Button with :focus-visible matching reports outline-style none and paints no
ring today. The field ring is therefore a box-shadow composed with the well's
inset shadow, which the same base layer cannot override, and that is now the
package rule. Button is left alone because its ring would have to be restated
per variant; it keeps the suppressed outline until a follow-up either gives it
the box-shadow ring or scopes the global reset.
No caller moved. 93 Input, 30 Textarea and 67 Label uses across 71 files still
import the Radix and Tailwind versions, and those files are deleted only when
their in-repo callers reach zero.
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ui): give field controls the control type step
The field family set its own text at weight 400 with no token behind the
disabled dim, which reads as a second opinion next to the type rule rather than
an application of it. src/tokens/RULES.md says controls are 13 at weight 500
with text.controlTracking, and Button already renders that way, so an input and
a button placed next to each other disagreed on the same step.
Set the shared well to weight 500 so every control in the family inherits the
control step; the size style still owns the size, and the tracking was already
there. Add field.disabledOpacity so the 45% dim is one value the control and its
label read instead of the same literal written in two files, and point the
dimensions that land on the space scale at it (space.2, space.3, space.1.5).
The 10px inline padding of the 32px control stays a literal because the scale
has no half step between 8 and 12. Rename paddingYBlock to paddingBlock, which
said the same thing twice. The gallery's focus stand-in picks up the same weight
and tracking so it keeps matching the control it stands in for.
Measured in Chromium in both palettes: every control now reports 13px at weight
500 with -0.13px tracking, 12px at the 28px step, the label 12px at 500, and the
disabled control and its help text 0.45.
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(components): move every field caller onto @lody/ui
The field primitives landed with nothing using them: 93 Input, 30 Textarea and
67 Label uses across the app still imported the Radix and Tailwind versions, so
the token mapping was proven on the gallery board rather than in the product,
and the two implementations of a text field stayed side by side.
Point every caller in packages/components and site-docs at @lody/ui. Label
becomes Field.Label, imported as UiField wherever a surface already had a local
Field of its own, so the label now carries the field's disabled state instead of
peer-disabled guesswork. Delete input.tsx, label.tsx and textarea.tsx with their
barrel exports and drop @radix-ui/react-label, which is the package rule that a
Radix file goes once its in-repo callers reach zero. site-docs drops its ambient
@/ui/textarea declaration for the same reason.
Spending the contract on real call sites found two holes in it. Textarea
accepted no style: its props were built by omitting style from <textarea> the
way Base UI does, but Base UI re-adds it and this did not, so the message editor
could not set its own font size. PasswordInput typed its props from a raw
<input>, so the HTML size attribute collided with the token step of the same
name; a wrapper around a primitive follows the primitive's props. form.tsx also
kept typing FormLabel from @radix-ui/react-label while rendering a Base UI
label, which is now typed from the element it renders.
pnpm typecheck passes across the workspace, @lody/ui keeps its 24 tests, and
oxlint, the platform and public boundary guards, lint:i18n and docs check are
clean. The lockfile carries only the removed dependency.
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ui): one type step for the whole control ladder
Control text was sized by step: 12 at 28, 13 at 32 and 36. That reads as a
typographic preference, but it is wrong for this system specifically.
These rules give each channel one job and then forbid a second. `accent` marks
live state and is never a button fill; `separator` divides rows and never wraps
a surface; disabled is an opacity and not a colour. Type size is already spoken
for the same way, as a role rather than a density: controls 13, prose 14, labels
and help 12. That is the type axis restating what the colour axis says with
label, secondaryLabel and tertiaryLabel, the thing versus about the thing.
Density already owns the 28 / 32 / 36 height and the radius that compensates for
it, so letting size drive type as well is the channel overloading the rest of
the document bans.
The collision was visible in the tokens: field.textSmall and field.labelSize
both resolved to footnoteSize, so at the 28 step a control's own value was
typographically indistinguishable from the label describing it, in a system
whose first colour rule is that those are different things.
Give the ladder one step and one token to carry it. field.text and button.text
replace the per-size text tokens, because tokens whose values happen to match
are an invitation to drift apart later. Button's small moves from 12 to 13: a
compact button and a dialog button are the same command at two densities and
should not disagree about how their label is set. Button's mini keeps 12 and is
left alone; at 24px it is not on the ladder these rules define, and whether it
belongs there or with the 16px things the radius rule mentions is a separate
question. RULES loses the sentence sanctioning 12 at the 28px step, which was
documentation written to justify the implementation.
Measured in Chromium in both palettes: button and input at 28, 32 and 36 all
report 13px at weight 500 with -0.13px tracking and radius 8 / 10 / 10, mini
stays 24px at 12px and radius 6, and the field label is now visibly smaller than
the value inside its control.
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ui): give Button a focus ring the shell cannot suppress
Button asked for its focus ring with an `outline`, and the product shell ends
its Tailwind base layer with `*:focus, *:focus-visible { outline: none
!important }`. Layer priority is reversed for important declarations, so no
ordering lets a StyleX rule win: a focused Button with :focus-visible matching
reported outline-style none and painted nothing. Keyboard users had no focus
indicator on any button in the desktop app, and the package's own rule already
said a ring is a box-shadow, which left Button as its only violator.
Compose the ring into each variant's box-shadow. It has to be restated per
variant rather than added by a second class, because CSS cannot append to a
box-shadow list and each variant owns a different edge: primary and destructive
an ink highlight, secondary a raised shadow, ghost and link nothing. button.ring
and button.ringWidth carry it, and ring joins the palette theme so it resolves
against a forced subtree rather than the document root.
:active stays none, which is the press the rules describe, translate 1px and
drop the edge. Putting the ring there instead would draw it on an ordinary mouse
press, since a pointer press matches :active while :focus-visible stays false.
The cost is a keyboard-held press losing the ring while the key is down, which
is the smaller of the two errors.
The board gains a focus row for Button beside the one the field already had,
built from the same tokens and reading its shadow back off the node.
Measured in Chromium in both palettes: all five variants report the accent ring
composed with their own edge on keyboard focus, rgb(93, 141, 239) in Lody Light
and rgb(255, 199, 153) in Vesper. A mouse press reports box-shadow none with
translateY(1px) and no ring, and the edge returns on release.
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ui): stop Field parts crashing outside a root, and ring on aria-invalid
Two defects, both found by review rather than by the tests.
Base UI's Field Label, Description and Error call useFieldRootContext(false),
whose optional flag is false, so they throw without a Field.Root; only Control
passes optional and survives alone. The takeover note had this backwards, and
the caller migration was written against the wrong reading: all 25 files that
render UiField.Label do so with no root, including the login page, every
settings panel, onboarding and several dialogs. Loading the UI/Switch story
reproduced it, a blank surface and Base UI error #28, so this was a crash on the
way to shipping rather than a theoretical gap.
Those three parts now fall back to a plain label, p and div when no root is
above them, styled identically and still honouring render through Base UI's
useRender. A label with htmlFor is meaningful on its own, and the only thing
lost outside a field is field state, of which there is none there. Wrapping 25
surfaces in a root instead was rejected: that reshapes the DOM and layout of
files this work should not be touching, to work around a primitive that should
not crash.
The invalid ring also read validity only from Base UI's state.valid, so a
caller marking its own control, as agent-role-form does, put aria-invalid on the
DOM and got no ring: a screen reader announced invalid while nothing looked
wrong. Field.Root renders its validity as aria-invalid, so the attribute is not
a second source of truth but the rendered form of the one truth. src/field/
invalid.ts reads it for Input and Textarea, and every ARIA value except false
counts, grammar and spelling included. StyleX has no attribute selector, so the
read happens in JS rather than in a style condition.
Tests cover both: the parts rendering outside a root with matching classes, and
the ring appearing for each ARIA value that means invalid and staying away for
the ones that do not. All three ring tests were confirmed to fail against the
previous behaviour rather than pass vacuously. The board gains an aria-invalid
sample beside the Field.Root one.
Model: claude-opus-5[1m]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add the @lody/ui choice controls The field family covered the controls a person types into, while every control a person picks with still rendered Radix with Tailwind classes the new token rules define no equivalent for. checkbox.tsx and radio-group.tsx reached for data-[state=checked]:bg-primary and switch.tsx for bg-switch-track, and primary is exactly the concept the redesigned system does not have: the rules replace it with an elevation rung plus ink. Their disabled treatments were 50% where the rules say 45%, and their focus rings were ring-offset haloes the rules ban and the desktop shell suppresses anyway. Migrating them one at a time across that gap would have let each control invent its own checked colour. Add Checkbox, Radio and Switch to the same field token group rather than opening one of their own, and settle the state the text controls never had to answer: a control that holds a value is ink, the label fill under a background mark with shadow.inkEdge on top, because the rules reserve accent for live state and never a fill. src/field/well.ts grows box, checked and checkedInvalid beside base and invalid, so the family still defines its resting, ring and disabled appearance in one file; the ring is restated for the ink edge because CSS cannot append to a box-shadow list, which is the same shape Button already uses per variant. Each renders a real <button> through Base UI's nativeButton rather than the default <span>. A span does not match :disabled, so the family's one disabled mechanism would have needed a second copy read from React state; a span is not a labelable element, so a <label> click would not toggle it; and the repo's tests already query button[role="switch"]. A disabled control leaves the tab order in exchange, which is what a disabled <input> in the same family already does. Radix modelled the mixed checkbox as checked="indeterminate", so every call site typed the value as boolean | 'indeterminate' and narrowed it back. Base UI has a separate indeterminate prop, and the two select-all sites now pass both. A mixed box wears the checked ink with a dash and announces mixed rather than falling back to a tick it does not hold. All 33 callers moved onto the primitives and checkbox.tsx, switch.tsx and radio-group.tsx are deleted with their barrel exports and their three @radix-ui dependencies. radio-group.tsx had no caller and no barrel export, so Radio and RadioGroup arrive with none either. Three callers were carrying the old dimensions or rebuilding the control's appearance and lost those classes; pointer-events-none and shrink-0 stay, because they are the caller's constraint. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: add PointerEvent polyfill and act import to Switch component tests The new @lody/ui Switch component uses Base UI React's Switch, which depends on PointerEvent in the dispatchClickWithModifiers handler. Tests interacting with Switch components need a PointerEvent polyfill in jsdom. - Add TestPointerEvent class to both test files - Install PointerEvent polyfill in beforeEach - Import act from react-dom/client for consistency with React 19 - Fixes CI test failures in mobile-about-developer-mode and tasks-beta-gate Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(ui): give pills the round corner shape, not the squircle The switch track shipped as a rounded rectangle. `well.box` puts `corner-shape: squircle` on every control in the family, and the track, the radio and Button's pill then set `border-radius: radius.full` expecting a stadium or a circle. A squircle does not degrade to one as the radius grows: it draws a superellipse at that radius, so a 28x16 track becomes a rounded rectangle and a 16px box becomes a squircle rather than a circle. Measured in Chromium against `corner-shape: round` at the same radius, the two are plainly different shapes. The corner rule said `corner.shape` rides along with every radius, which was written before anything used `radius.full`. Add `corner.round` beside `corner.shape` and give `radius.full` the exception in RULES.md and AGENTS.md: that radius means a pill or a circle, and only the round shape draws one. The switch track and the radio box take it; the thumb and the dot never needed it, because `corner-shape` does not inherit and their default is already round. Button's pill carried the same defect from the start and is fixed with them. The gallery's `radius.full` chip did too, so the board was drawing a rounded rectangle under the label "pills"; it now shows the round shape beside the four squircles, and the Corners rule states the exception. Three tests pin it, deriving both expected classes from StyleX rather than writing them down: the switch and the radio carry the round class and not the squircle, the checkbox keeps the squircle at its 5px radius, and the two classes differ at all. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: import act from react instead of react-dom/client The act function should be imported from React, not from react-dom/client, for compatibility with Vitest's jsdom environment. This fixes the '(0, act) is not a function' error in component tests that use Switch. - Import act directly from 'react' in both test files - Fixes CI test failures in mobile-about-developer-mode and tasks-beta-gate - All 11 tests now pass locally Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(components): prove the selection loop stops by its effect, not a frame count `scrolls at the edge, selects newly reached messages, and stops on release` counted outstanding requestAnimationFrame callbacks to show the auto-scroll loop had stopped. That held only while the loop was the sole rAF user under the component. The migrated Checkbox brings Base UI's transition-status bookkeeping with it, and Base UI's scheduler keeps its last native frame queued on purpose, turning it into a no-op rather than cancelling it, so a checked box always leaves one frame outstanding and the count can never reach zero again. No product code can change that; the assertion is what has to move. Assert what the test's own name claims instead: after release, draining whatever frames remain must not advance the scroll. That keeps its teeth -- neutering `end()` makes the scroll run on from 8.87 to 26.6 and the test fails -- and it no longer depends on which library happens to hold a frame. Also drop the `React` default import 89f6583 added to mobile-about-developer-mode, which that file never uses; tasks-beta-gate keeps its own because it references `React.ReactNode`. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct the note's claim that the local test failure was environmental The note excused `packages/components`' suite failing with `act is not a function` as pre-existing, on the evidence that the base commit failed the same way. That evidence was worthless: React 19 exports `act` only from its development build, so the suite needs `NODE_ENV=development` and fails on both sides without it. Dismissing it hid the three failures that then reached CI. Record what actually happened, including why an outstanding-frame count cannot survive a Base UI control in the tree. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Claude Code <noreply@github.com>
* feat(ui): add the @lody/ui Select and Combobox primitives The field family covered the well rung; Select and Combobox are the first controls in it to open a floating list, so they need a second vocabulary. The trigger stays in the `field` group — same size ladder, ring, invalid ring and disabled opacity as an Input — while the list takes a new `popup` group on the floating rung, shared with the menus that follow rather than borrowed from a control it does not resemble. `popup/surface.ts` holds the appearance both lists share, the way `field/well.ts` holds the one the controls share, so a row cannot be styled twice. Select.Content and Combobox.Content assemble Base UI's portal, positioner, popup, list and scroll arrows once, and a `PopupContainerProvider` lets a host that owns a focus scope name where a popup mounts. Model: claude-opus-5[1m] * feat(ui): migrate every Select caller onto @lody/ui All twelve callers move to `Select.Root`/`Trigger`/`Value`/`Content`/`Item`, and `packages/components/src/ui/select.tsx` is deleted with its Radix dependency. The classes that rebuilt the deleted control's density — `h-9`, `h-8`, `text-xs`, `w-full` — are gone; the size ladder is the primitive's prop. Three differences from Radix cost real work rather than an import swap: - `Select.Value` resolves the label of the current value from `Select.Root items`, not from the rows, so every caller whose row text differs from its value now states its list once and renders both from it. - `null` is Base UI's empty value, so a handler for a control that cannot be cleared narrows it rather than casting. - A Base UI popup inside a Radix dialog has to mount in the dialog panel, which owns the focus scope and the scroll lock. `DialogContent` names itself as the popup container once, and the list anchors under its trigger rather than overlapping it, which also keeps it off the fixed-position path a `translate`d panel reinterprets. Model: claude-opus-5[1m] * docs(ui): record the popup token group and the Select/Combobox slice The token rules gain a "Popups and lists" section: why a trigger reads `field` while the list it opens reads `popup`, how a row's radius derives from the popup's inset, and why both row fills are mixed from the floating rung instead of taken from `hoverFill` and `selectedFill` — measured, each of those resolves to invisible on that rung in one of the two palettes. The spec states the intent and stays `draft`. The note records the three defects the browser found that no test did, and what driving a Base UI popup in jsdom actually needs. Model: claude-opus-5[1m] * fix(ui): keep a popup in the palette, the row edge and the empty row honest Four defects found by pulling the branch down and clicking the controls, all the same class as the three the board already caught: a popup is not where it was written, so anything that depends on where it is breaks quietly. - `Combobox.Empty` stays mounted so a screen reader has a live region to announce into, and it carried a 28px min-height whether or not it held a message, so every popup opened with a blank row. It collapses through `:empty` — rendered and in the accessibility tree at zero height, which `display: none` or `aria-hidden` would not be. - A portalled popup left the subtree that declares its palette and inherited the document's, so a dark panel opened a light list. `ThemeRoot` publishes its mode and `Content` re-declares the palette on the positioner. - The shell rings any focused `[tabindex]` through a zero-specificity `:where()` rule, and Base UI moves focus onto the highlighted row, so a stray accent border followed the keyboard down the list. A row states `box-shadow: none`: it owns its edge, which is no edge. - With the palette fixed the board's stand-in and the real list are identical, so the board now says which is which rather than leaving a reader to guess. Model: claude-opus-5[1m]
@lody/ui already owned the floating surface a Select and a Combobox open, but every menu in the product was still Radix with Tailwind classes reaching for a vocabulary the token rules do not have. menu-styles.ts computed the menu's edge as a color-mix against --background and painted it as a hairline ring, which is a workaround for a border token in a system whose rules say edges are wells, raised shadows, elevation shadows and the focus ring. Rows were min-h-8 at rounded-lg inside a rounded-xl surface, neither pair on the nested-radius rule; group labels were 10px at tracking-[0.6px]; disabled was 50% where the rules say 45%. Three files carried that vocabulary in three copies, and menubar.tsx, the fourth, was shadcn's default and had no caller at all. Add the family as one component rather than four. A menu is the surface a list already opens: it reads the popup group and src/popup/surface.ts, and replaces exactly one declaration. A list states min-width: var(--anchor-width) because the control it belongs to shows the value the list holds; a menu is opened by whatever the surface already had there, often a 28px icon button, so it states popup.menuWidth. Base UI has no separate dropdown part, so Menu is the dropdown menu; ContextMenu restates only its root and trigger and Menubar only the bar and its names, and both re-export Menu's rows. Three tokens join popup rather than a group being opened beside it: menuWidth, destructive and destructiveHighlight, the last mixed toward destructive for the same reason highlight is mixed toward label, because the palette's named fills collapse into this rung. The rise is restated as a distance from the anchor rather than from the page: a menu flips to stay on screen and a submenu opens beside its row, so the popup starts one step further from what opened it on whichever side it landed. A context menu is anchored to the pointer, so ContextMenu.Content states no offset at all. A row's leading box holds one thing: a checkbox or radio row's mark is unmounted while the row is unticked, so a caller's glyph sharing that box would slide sideways every time the row was toggled. Post-close focus stays the product's, as Base UI's finalFocus; Radix's touch pointerdown workaround is dropped, because Base UI already tells a tap from a scroll. Three defects survived a green suite and were caught in Chromium, all the same shape: the board renders to static markup and a menu is portalled and unmounted while closed. A bare Menu.GroupLabel throws, which is the migration's sharpest edge since Radix's label rendered anywhere and there are 52 of them; popup.menuWidth read back as 0px because the stand-in reused the list's style; and two JSX attribute strings rendered as their own escape sequences. The 41 dropdown-menu and 11 context-menu callers are NOT migrated here. Radix's menus have about a thousand usage sites and this is not an import swap: onSelect with preventDefault becomes closeOnClick, asChild becomes render, align and side move to the positioner, and DropdownMenuSearchInput exists only to fight Radix's typeahead. Menubar is the one piece that is complete, having had no caller, so its Radix file, barrel export, @radix-ui/react-menubar dependency and attribution entry are gone. Model: claude-opus-5[1m] Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#634) * feat(ui): add the @lody/ui Popover, Dialog family and Tooltip @lody/ui owned the floating surface a Select and a Menu open, but everything else that sits over a page was still Radix with Tailwind classes reaching for a vocabulary the token rules do not have. popover.tsx and tooltip.tsx carried border border-border in a system whose first rule is that no border token exists; dialog.tsx and alert-dialog.tsx disagreed about their own animation, one having been changed to drop the zoom the other still had; sheet.tsx was a cva over four sides. dialog.tsx also held the one piece of real design in the set, naming its own panel to PopupContainerProvider, which belongs in the primitive rather than in a product file. Add Popover, Dialog, AlertDialog, Sheet and Tooltip. A popover is not a new surface: it reads the popup group and src/popup/surface.ts and replaces five of a list's declarations, the --anchor-width a list takes because its control shows the value it holds, the 4px inset that lets a row bleed to the edge, and the three that make type a control's rather than prose. Two tokens join popup rather than a group being opened beside it, panelPadding and panelGap, plus description, which resolves to the same colour as groupLabel today and means something else. test/popover.test.tsx pins the five as a count the way the menu pins its one. Dialog, AlertDialog and Sheet are one family under a new dialog group sharing src/dialog/surface.ts, for the reason field serves the whole control family: they differ in how they arrive and in what may dismiss them, not in what they are made of. Each names its own panel as the container every Select, Combobox and Menu inside it mounts into. An alert dialog's answers stay Buttons rather than becoming parts, because which variant an answer takes is the surface's decision; that drops the old AlertDialogAction/Cancel pair, which decided both. Safe areas are read with env() rather than a host-published variable, so the package stays platform-neutral. Tooltip is the one floating part that does not read popup. The ladder puts a menu, a popover and a list on raisedBackground under shadow.popover and then names the tooltip apart, label with shadow.medium: an inversion, because a popup is a place to act and a tooltip only names one. It never takes the pointer and sits above every popup, because what it names may itself be in one. Two defects the tests caught: the panel container was a ref, and React attaches a child's refs before its parent's, so a Select opening in the same commit as its dialog read null and portalled to the body, the one case the mechanism exists to prevent; and Escape does close a Base UI alert dialog, which the first draft claimed it did not. Three more were caught only in Chromium. Every sheet collapsed to its content height at the bottom of the window, because StyleX keeps one class per property key and does not know top, inset-block-start and inset-block name one property, so both survived and the cascade picked top: every inset in dialog/surface.ts is a logical longhand now and each edge states all four. The board's dimension probes reported their flex-shrunk width rather than the token, and its dialog stand-in ran off its column twice. Sheet is migrated: all five callers move and packages/components/src/ui/sheet.tsx is deleted with its barrel export. Popover, Dialog, AlertDialog and Tooltip are NOT, and the reason differs. The deleted PopoverContent folded useSafeAreaInsets into collisionPadding on every popover, a product policy @lody/ui cannot hold, and two call sites use PopoverAnchor. Dialog, AlertDialog and Tooltip are about a thousand usage sites, and Base UI 1.7 makes a tooltip visual-only with no role and no aria-describedby, so a control whose only accessible name came from its tooltip goes nameless after a mechanical port; each of those 75 files has to be read rather than swapped. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(ui): replace Sheet with Drawer, built on Base UI's own The first version of this branch shipped a Sheet built on Base UI's Dialog with a side prop. That is Base UI's Drawer reimplemented out of the wrong primitive: its own subtitle for Drawer, "a panel that slides in from the edge of the screen", is word for word what that Sheet's documentation said. It is the mistake the menu note already recorded under another name — a second name for the same component and a second thing to keep in step. The behaviour is the real argument. A panel that slides in from an edge promises that it can be sent back, and on a touch screen a person will try; a dialog cannot answer that gesture at all. The repository had already voted: 23 files reach for vaul precisely because the Dialog-based sheet was not enough, and every one of the five callers this branch migrates is a mobile bottom or side panel, so Sheet would have made five surfaces worse on touch than the vaul drawers beside them. Naming the primitive Drawer also resolves rather than deepens the collision with the eleven vaul files already named *-sheet.tsx. A drawer is laid out by a viewport rather than positioning itself, which is the mechanical reason the two cannot be one component: a dialog centres itself with translate(-50%, -50%) and spends the one property CSS has for movement, while Base UI puts the panel in a fixed Viewport whose alignment picks the edge and leaves the panel's transform free to carry --drawer-swipe-movement-x/y. test/drawer.test.tsx pins that the drawer panel never carries the dialog's centring class. Two axes. The edge is stated in writing direction and the physical swipe is derived from it, flipping only the inline pair, so a drawer on the start edge departs leftwards in a left-to-right document and rightwards in a right-to-left one; the direction is read from the document rather than taken as a prop. Flush or inset is the second: flush meets the window, squares the two corners that touch it and pads its own content clear of the safe area, while inset floats at dialog.drawerInset with all four corners and takes the safe area as viewport padding. The gap is the viewport's padding rather than the panel's margin for the same reason the position is — a margin would be one more thing competing with the drag. The scale gains duration.slow at 320ms, because a drawer crosses the window and 180ms over 600px reads as a snap rather than a slide. The backdrop lifts with --drawer-swipe-progress rather than only at the end, so a half-dismissed drawer shows a half-lit page and the gesture reads as reversible. The modal rung is otherwise unchanged: Drawer reads the same dialog token group and the same dialog/surface.ts as Dialog and AlertDialog, so the appearance work already on this branch carries over. sheetSize becomes drawerSize. All five callers move from Sheet.* to Drawer.*, with session-detail.tsx aliasing the new primitive as UiDrawer while the vaul Drawer there still holds the bare name. Verified in Chromium: all eight configurations measured against the viewport — flush and inset on each of the four edges — with the derived data-swipe-direction read off each. A drag cannot be synthesised from JavaScript, since Base UI reads real pointer input, so what was checked instead is this package's half: setting --drawer-swipe-movement-y moves the panel by exactly that much and --drawer-swipe-progress fades the backdrop to match. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): stack the drawer viewport, not the panel inside it The overlay painted over the drawer: the panel rendered greyed under its own backdrop, which looks subtly wrong rather than obviously broken. `position: fixed` creates a stacking context, so the `z-index: z.dialog` on the panel ordered it only against its own siblings inside the viewport. The viewport itself declared no `z-index`, counted as `auto` against the page, and the backdrop at `z.dialogBackdrop` won. The rung's stacking belongs on the viewport, because that is the element stacking against the product shell; the panel inside it now states none rather than claiming something it cannot honour. Unlike the other defects on this branch, this one was checkable without a browser — it is a fact about which classes compile onto which part. test/drawer.test.tsx pins it: the viewport carries the same z-index class the dialog panel does, the backdrop carries the other, and the drawer panel carries neither. Reverting the fix fails that test. Verified in Chromium with elementFromPoint over the panel for all six modal surfaces — flush and inset drawers on each edge, the dialog and the alert dialog — where the topmost painted element is now inside the panel in every case. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four files were assembling the same segmented control out of Tailwind and disagreeing while they did it: tabs.tsx gave a list h-9 bg-muted p-1 with data-[state=active]:bg-background on the trigger, context-switch.tsx restated it at h-10 with a border and a second version for a dark tone, chat-landing.tsx restated it again with a different shadow, and mobile-stats-settings.tsx used grid-cols-4 and no height at all. Two carried a border in a system whose first rule is that no border token exists, and the four heights included one that is not on the control ladder. accordion.tsx was shadcn's default with border-b and two keyframes that exist nowhere in the token rules; collapsible.tsx was three re-exports of Radix with no design in it, so two of its three callers wrote no reveal at all. Add Tabs, Accordion and Collapsible as one family under a new disclosure token group. They are one family for the reason dialog covers three modals: each is a trigger and the thing it shows, and what differs is the arrangement. A strip lays the choices side by side and swaps the panel under them, an accordion stacks them and opens one in place, a collapsible is one of those rows with no list around it. The group is deliberately not field: none of the three holds a value, so none takes a name, a validity or a field around it. The strip is the elevation ladder read twice over, a well-rung track with one thing raised out of it, which is the pair a Switch takes and the pair the Edges rule already gives "you can press this". The indicator is one element that slides rather than a fill on each tab, because the strip is a single control, and Tabs.List draws it rather than the caller for the reason a submenu's chevron is drawn by its row. It is anchored physically and moved with translate: Base UI publishes the selected tab's box as pixels from the list's top-left corner, so reading them from the inline edge would put the pill on the wrong tab in a right-to-left document. The size is stated once on the strip and the tabs follow it, including the nested radius, and stretch is one prop because it is two facts that have to agree. An accordion row has no fill in any state, so the line to the next row is the separator the rules give a list, drawn as an inset shadow because an accordion's rows are its own children and there is nowhere for a caller to put a part. A revealed panel's padding rides on a child of the panel: Base UI animates the height from a size it measures with scrollHeight, which counts padding, so a padded panel is cropped by exactly its own padding under border-box and overshoots by it under content-box. A Collapsible's trigger stays Base UI's, unstyled, because a lone disclosure is opened by whatever the surface already had there. One defect survived a green suite and was found by opening the board: a disabled tab was not dimmed. Base UI puts no native disabled attribute on a tab or an accordion row — both stay focusable so a keyboard reaches them and hears aria-disabled — so :disabled never matched. Both now take the dim from Base UI's state, and the tab freezes its hover colour with it. Migrate seven surfaces and delete the Radix accordion.tsx and collapsible.tsx with their barrel exports. ContextSwitch stays: its tone prop paints a second strip for the landing page's dark hero, which wants that hero to declare a palette with ThemeRoot rather than a rename, and its unavailable tab must still answer a click. Model: claude-opus-5[1m] Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add the @lody/ui feedback family Five files, five vocabularies, three of them reaching outside the token rules. alert.tsx was a cva with rounded-lg border px-4 py-3 — a border in a system whose first rule is that no border token exists — whose destructive variant coloured the whole message red and left its surface the card. progress.tsx was bg-primary/20 holding a bg-primary child, which no rule here names: accent is for live state, and an alpha of the primary colour is neither. skeleton.tsx was animate-pulse bg-primary/10, where the rules say a skeleton is one of the few things that takes a gray. loading.tsx welded two decisions together, the mark and where it goes. The product had also grown a status palette in Tailwind that @lody/ui could not see, so a warning in a settings page and a warning in a primitive were two different oranges by construction. Add Alert, Toast, Progress, Skeleton and Spinner as one feedback family, and two semantic colours — success and warning — whose values are the product's own --status-success and --status-warning in both palettes. They name an outcome rather than an action, so destructive stays the colour of a thing that destroys and of a thing that failed, and there is no third red. An Alert and a Toast are one message on two rungs: an alert stays on the page it is about and takes the card rung, a toast arrives over that page and takes the floating one. Neither is modal, because a message does not have to be answered. A tone is a tint and a mark and never a fill: the tint is 8% of the tone mixed into the rung's own background, the mix a destructive menu row already uses, and it is mixed in feedback/surface.ts rather than frozen into tokens because it is a mix of a surface and the two surfaces are on different rungs. The mark belongs to the tone rather than to the caller — a caller free to choose a glyph can put a tick on a failure — and so does the role: alert for a failure or a warning, which interrupts a screen reader, status for the rest, which waits. Toast.Provider renders its own viewport, so a surface wraps its app once rather than keeping a provider, a portal, a viewport and a list in step, and the tone travels as Base UI's type from the call that reported it. A bar is accent, because the rules name the running indicator; a bar with no value is not a bar at zero but the same track with a band crossing it. It takes a tone too, for a quota nearly spent and for a meter that is not live at all. A skeleton takes a gray and takes its room as props, because a size passed as a class lands in a specificity fight with the shape's own height. A Spinner is the mark rather than the state, drawn in currentColor so the one inside a ghost button takes the button's ink, and it keeps turning under reduced motion because a still spinner says the work has stopped. Two defects found by opening the board rather than by the tests. A neutral alert was invisible: in the light palette the card rung and the page are the same white, so it now takes a 4% tint of label, the derivation a popup's highlight already uses. And the indeterminate band swept to 350% of its own width, leaving the bar empty for a quarter of every cycle; it ends at 250%, where its leading edge reaches the end of the track. A third was structural: a toast's arrival was a keyframe animation on the starting status, which lasts one frame and cancels the animation — it is a transition between two declared ends now. Migrate seventeen surfaces and delete alert.tsx, progress.tsx, skeleton.tsx and loading.tsx. The toast is built but migrated nowhere: 61 files and 267 call sites reach for sonner, and two toasters alive at once would stack messages in two places, so that flip is one change of its own. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): give an icon-only Button the box its glyph needs This package's glyphs state their size as 100% of whatever holds them, and every holder gives them a 16px box: a menu row's leading box, a Select's chevron, a message's mark. An icon-only Button gave none — it sets only a square width and no inline padding — and StyleX has no descendant selector with which a button could reach the glyph inside it. So a cross filled its button edge to edge: measured in Chromium, the Dialog's close and the Toast's close were both 28x28 glyphs in 28x28 buttons, where a Select's chevron beside them was 16. Add button.iconSize and render that box around an icon button's children. The 122 icon buttons in @lody/components size their own lucide icons at 14 or 16px, so the box changes nothing for them and constrains the three that pass a caller's icon straight through. The board's own PlusGlyph had been working around the same hole with a hardcoded width="14", which is gone now. The defect predates this branch — it has been in the Dialog's close since the overlay family landed — and it is fixed here because the Toast's close is the second part to inherit it. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add the @lody/ui Table and Pagination
Rows of records, and the way to the ones that did not fit. One `table`
token group covers both, the way `dialog` covers the Drawer: a pager
exists because a table did not fit, the two sit on the same rung and
state the same size.
A table is the only part of this package with no surface of its own —
no background, no shadow, no radius — so it is rows on whatever the
surface around it already was, and its single edge is the `separator`
between one row and the next. The head takes that line because the row
after it is the first record; the last record draws none. That edge is a
border rather than the inset shadow every other row here uses, because
under `border-collapse: collapse` a row's box-shadow is not painted and
under `separate` its border is ignored.
Hover is opt in through `interactive`: the Radix table this replaces lit
every row, and its one caller had to turn that off again with a class.
The two fills are the palette's own `hoverFill` and `selectedFill`,
which the rules name for a row on the page rung; `selected` is a fill
and nothing else, since `aria-selected` belongs to a row in a grid.
`Pagination` is one control rather than a kit of parts: the window is
one width from the first page to the last and admits a gap only where it
hides more than one page. `compact` says where you are when there are
too many pages to list, and `jump` commits on Enter or on blur rather
than on every keystroke.
Base UI ships neither component, so both are written the way Skeleton
and Spinner are; Base UI enters through the Button and Input the pager
is built from. `packages/components/src/ui/{table,pagination}.tsx` are
deleted and their callers migrated — the onboarding summary and the
paged file viewer, which loses its keystroke navigation.
Model: claude-opus-5[1m]
* refactor(ui): make Table a column-driven primitive
The first version of this branch was a styled `<table>` — parts mirroring
the HTML elements, with the design work all in the skin. It was reviewed
as conventional, and this replaces it.
A column is stated once. `Table` takes `columns: TableColumn<Row>[]`, and
from that one fact it owns what a caller assembling elements has to get
right every time: the widths (through a `<colgroup>` that reaches cells
the caller never writes), the ordering (one column at a time, on the root,
so two columns wearing the arrow cannot be expressed), the selection (a
`Checkbox` in the row with the head's box derived — none, mixed, all — and
keys off screen surviving a select-all), the empty row's span, the head
that stays, and the stack a table becomes when its own container is too
narrow. The evidence is in this repository: both surfaces that draw a
table write their `grid-cols-[…]` template in the header and again in the
row, and one renders every cell a second time for a narrow window.
`interactive` becomes `onRowPress`, which brings the hover with the thing
that justifies it and takes the keyboard along: Enter and Space press a
row, and it rings where the keyboard is.
The border exception is deleted. A six-case Chromium probe showed the
earlier claim was wrong — the inset shadow had been hidden by an opaque
cell background, not by the table model. Under `border-collapse: separate`
a row's line, a row's focus ring and a sticky head's line all work, while
under `collapse` a sticky head loses its border, so the table is back on
the `inset 0 -1px 0` every other row in this package draws.
Narrow is the table's own width, not the window's: StyleX supports
`@container`, so a table in a 360px side panel stacks on a 27" screen.
Without it the primitive could migrate nothing — all three real tables
here collapse by hand today.
`review-policy-setting.tsx`, which hand-rolled role="table" around a
template written twice, is migrated; `parts.tsx` keeps the elements for a
table that is not a list of records, which is what the onboarding summary
is. The board caught a defect the tests could not: a table that renders
its own rows passes `last`, so `:last-child` stopped applying and no body
row drew a line at all.
Model: claude-opus-5[1m]
Three files, three vocabularies, each reaching outside the token rules in a different direction. card.tsx was rounded-lg border border-border shadow-xs — a border in a system whose first rule is that no border token exists — with p-6 stated once per section, and a text-2xl title that is not a step on this type scale. badge.tsx was a cva whose default variant filled the chip with ink, which the rules give a stored value, plus a hover and a focus ring: a badge that looked like a primary Button and invited a press it does not answer. Of its 44 call sites 38 overrode the size, and the onboarding screen had grown a PROVIDER_STATUS_CHIP constant and four hand-mixed colour pairs for statuses the palette now names. separator.tsx defaulted to decorative, which is role="none", in a system where the one place a line is allowed is structural by definition. Add Card, Badge and Separator as three components rather than a family: they share no colour, measurement or state, only the question every part here answers — what its edge is. A card's edge is its shadow, a badge has none because it is on no rung, and a separator is the one edge the rules allow. A Card is a Dialog's panel one rung down and takes the same parts, because what separates a panel that owns the window from a block that owns a region of a page is the rung and the heading step rather than what either is made of. It keeps its own token group for the reason a menu does not read field.background. Its title is headline: the rules reserve title for a page that is a page, so the eight migrated pages drop their off-scale 24px headings. A card does not nest — two of them are one fill twice in the light palette — and renders no control of its own: interactive marks the card and the caller still brings the button, because what a press does is a product decision. A Badge is on no rung: it appears on a page, a card, a menu row and a modal panel, three of which the rules already record as collapsing the ladder's named fills. Its tone is therefore a film of that tone over whatever is underneath, the form a destructive ghost button's hover already takes. Its words stay ink in every tone, and that is measured: light-palette warning is 2.8:1 on a near-white surface, a colour tuned for a 16px mark where the bar is 3:1, and a badge is never wordless — the tint carries the tone and the word carries the fact. Five tones, the fifth being running, which Progress adds for the same reason. No hover, no focus ring, no filled variant; a chip a person can press is a Button. A Separator takes no token group, because it is the edge rather than deriving one — a separator.color would be a second name for one fact. It is announced rather than decorative, carries no margin of its own, and stretches to its row when vertical instead of taking a percentage of a height the row has not got. One defect the board caught rather than the tests: card.hover cannot be the region rung the elevation table named for it, because in the dark palette secondaryBackground and elevatedBackground are the same value and the hover would have been invisible in exactly one palette. It mixes the rung toward label at 4%, the derivation a popup's highlight and a secondary button's hover already use, and the ladder's table is corrected to match. Migrate twenty-nine surfaces and delete badge.tsx and separator.tsx with the orphaned @radix-ui/react-separator dependency. billing-setting-pure.tsx keeps the old Card: its table cards need a flush surface this system does not have yet, and its tinted callouts are Alerts, which is a change that can be read against that file. Model: claude-opus-5[1m] Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every Table sample on the gallery board rendered as the stacked list, including the one sample whose entire point is that it is the exception. The section read as a wall of label-and-value pairs rather than as a table, and the primitive was not at fault: the board handed each sample a 342px box, under the 480px at which the rules say a table stops being columns. Two board defects. The section used the two-palette split — minmax(340px, 1fr) twice across a 1120px page — and the 168px legend column beside it left 342px. PaletteSplit now takes wide, one palette per row, and the Table section asks for it; samples measure 908px and the sample with a 320px box is the only one that stacks. And a sample's note shared the wrapping row with the sample and competed for width, so two tables of the same size were laid out 260px and 342px wide on one board. Notes are a Note part at flex-basis: 100%, which always takes its own line, and carry no max-width: a flex item's base size is clamped by max-width before the line is broken, so a 72ch note fits beside the sample after all and squeezes it to its minimum — the same bug wearing a reading measure. The 480px stack width is unchanged. It is conservative for three columns whose stated widths total about 300px, but it is the system's number and leaves room for the values rather than only the column names. Nothing in this package's tests can see any of this — jsdom applies none of StyleX's CSS — so it is read off the rendered page, which is why parts.tsx now carries the rationale for both splitWide and Note beside them. Verified in Chromium under both palettes: every sample 908px with real columns, the narrow one 320px and stacked. Model: claude-opus-5[1m] Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
) * feat(ui): add the @lody/ui Avatar and Kbd @lody/ui had every part that acts, reports or opens, and nothing for the two things an interface has to name but does not own: a person, and a key on their keyboard. avatar.tsx was a Radix wrapper with one size, size-8, and of its twenty-seven call sites twenty-one named a box and fifteen also named a type step for the letters — h-5 w-5 text-[9px], h-7 w-7 text-[11px], h-16 w-16 text-xl — two facts every surface had to keep in step, at eight different sizes with no decision behind the gaps. Four of them also restated the fill, the last putting accent — which the rules reserve for live state — under a static portrait. kbd.tsx reached the surface it sat on with a descendant selector StyleX cannot express. Add Avatar and Kbd as two components with two token groups and one question: neither stands for anything in this interface, so both take the gray ramp, which is what the rules already reserve for a thing with no role. Skeleton was there first; the rule's list grows by one rather than the palette growing. The rung picks the letters. Five rungs — 16, 20, 24, 32, 64 — each carrying its own type step and its own glyph box, so a caller states one fact instead of two that can disagree. The ladder is deliberately not the control ladder's 28/32/36: an avatar in a row is a mark beside text rather than a control, and the 28 and 36 the product had reached for were a row avatar that had drifted. A person is a circle at radius.full on corner.round; a thing is a tile, because a circle around a logo is a crop and the mark inside one was drawn square, and its corner is the radius-by-size table read per rung. The fill is a gray rather than a badge's film: what an avatar stands in for is a photograph, and a translucent face would show a sidebar row's hover through it. The letters take label rather than secondaryLabel, which is measured — on this gray the secondary label is 3.5:1 in the light palette, under the 4.5:1 that 8-to-11px letters need. An identity colour stays the product's and arrives as a style, so Avatar.Fallback is the one part here that merges a caller's style rather than replacing it. A Kbd is a key cap, and a KbdGroup the chord it is pressed in — a kbd around kbds, which is the shape HTML gives one gesture. It is never a control and never a menu row's shortcut: the rules give that slot plain trailing metadata, because a column of chips down a menu's right edge turns a quiet list into a keyboard diagram. The deleted descendant selector is replaced by the mechanism this package already trusts for a portalled palette: Tooltip.Content declares kbdOnInvertedTheme on its popup and every cap under it inherits, which unlike a [data-slot] selector also reaches a cap a caller wrapped in something of its own. Two defects the board caught rather than the tests, because jsdom applies none of StyleX's CSS. A 16px circle holding two initials laid out 20px wide and stopped being a circle: a flex item's automatic minimum size is its content's, and width loses to it, so the box and its fallback both give it up — an avatar is a ceiling as well as a floor. And WW at the first draft's 8px step measured exactly 16px against that circle, losing its outer strokes to the crop; the step is 7px, which measures 14px. Migrate twenty-seven avatar call sites and four key-cap files, and delete avatar.tsx and kbd.tsx. fallbackClassName goes with them, since everything it carried is the package's decision now. One Radix tooltip moves too: the sidebar's new-task button is the only surface that puts a Kbd inside one, and only @lody/ui's Content declares the inversion a cap there needs to stay readable. The hashed identity hue is still written twice, here and in MobileInitialLetterAvatar; moving it into the package would make the package own a brand colour, which is a product decision rather than a migration. Model: claude-opus-5[1m] Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: link the UI avatar and kbd note to its PR Model: claude-opus-5[1m] Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): make a badge's five tones tell each other apart The tones were reported as hard to distinguish in use, and measuring them against each other rather than each on its own showed why. Off the rendered board, the closest pair of composited chips was 0.019 apart in oklab in the light palette (neutral against success) and 0.026 in the dark one (running against warning) — a difference that survives a swatch comparison and does not survive a 20px chip glanced at in a row. Raise the films from 8% of label and 14% of a tone to 12% and 22%, which puts those closest pairs at 0.030 and 0.035. It is still a film: the word is what says which tone a badge is, and the tint only has to make that believable at a glance — but a film nobody can separate is a film doing nothing. And the ink was already failing. Raising a tint costs contrast, so the word was measured on its own chip, which the original change never did: secondaryLabel on a danger badge was 3.9:1 on the page and 3.4:1 on one inside a popup, against the 4.5:1 that 11px text needs. Those are the old numbers — the film's strength made a failing number worse rather than causing it. badge.label is colors.label now, which clears the bar in every tone on every rung in both palettes, by 9:1 at the narrowest, measured off Chromium's own compositing rather than modelled. A badge stays quieter than the thing it is attached to by taking the caption step, which is a size, not by taking a colour that cannot carry its own words. What the earlier reasoning got right and keeps: do not colour the words with the tone. warning at 11px is still 2.8:1. running against warning in the dark palette stays the closest pair at any film strength, and raising it only scales the gap: dark accent is hsl(27 100% 80%) and dark warning is hsl(43 96% 56%), sixteen degrees apart, so a wash of either over near-black lands in the same warm brown. Separating them means moving a semantic colour every focus ring, link and live indicator reads, which is not a decision a badge gets to make. Recorded rather than fixed. Model: claude-opus-5[1m] Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): let a badge's word carry its tone, because the film cannot The stronger film was still reported as indistinguishable in the dark palette, and chasing it with a bigger percentage was the wrong instrument. ΔE said the dark palette was better separated than the light one at every strength — 0.035 against 0.030 at the shipped film — and hid which pairs are close. In light the closest pair is neutral against success, a gray against a green, named apart whatever the distance says. In dark it is running against warning, where accent is hsl(27 100% 80%), a pale peach, and warning is hsl(43 96% 56%). Composited at 22% over near-black those are hue 61° at chroma 0.024 and hue 87° at chroma 0.053: two brown washes, twenty-six degrees apart, one of them barely chromatic. Raising the film scales both and separates neither. Every candidate to 60% was checked and the pair stays closest at all of them. So give the word the tone. The rule this replaces — do not colour a badge's words with its tone — was right about the raw tone and wrong to stop there: warning at 11px is still 2.8:1, and that measurement is untouched. A tone pulled halfway to label keeps its hue and gains the ink's contrast, and the worst of the four then measures 5.2:1 on its own chip, on every rung, in both palettes. label was already the ink, so one expression serves both palettes — it darkens toward near-black in one and lightens toward white in the other — rather than a palette-conditional value the component token groups have no way to carry. Half is measured too. At 60% of the tone the worst word is 4.2:1, under the bar on the floating rung where the chip is already a step darker; at 40% the hues wash out. This is the move the film could not make. A 20px chip's tint is a wash; its word is the mark a person looks at. Measured off the rendered board, the four words land 0.097 apart in oklab at the closest in the light palette and 0.048 in the dark, against 0.030 and 0.035 for the films under them — and in dark, running and warning become a peach and a gold at four times the chroma of their washes. running and warning are still the closest pair in dark. They are now a peach word on a brown chip against a gold word on an olive one, told apart at a glance, rather than two brown chips which were not. Moving them properly apart means moving accent, which every focus ring, link and live indicator reads. Model: claude-opus-5[1m] Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ui): give the board's rungs something else to stand on A card on the board looked like it had no background, and the report was "did you change something about background? the contrast is terrible". Nothing had changed. PaletteSplit's panel is elevatedBackground under shadow.card — it is the card rung — so a Card sample was being shown on itself. The light palette hid it, because there the card rung and the page are both white and the shadow still separates them; in the dark palette elevatedBackground is rgb(22,22,22) and so is the panel, leaving a card with no fill step and only a shadow already spent on the panel behind it. It reached further than the Card. The elevation ladder stood on secondaryBackground, which in the dark palette is rgb(22,22,22) as well — the same value as elevatedBackground — so a ladder of surfaces could not show its own card or modal step. An Alert takes the card rung by that ladder's own table, so its four tones were four tints of the panel under them. And the modal stand-in painted two of the three things its own caption says the rung states, on a surface that cancelled the first. Stand each of them on a different rung. The ladder stands on the page, where exactly one chip matches its ground — page — and that one is supposed to: a card that cannot be told from what it sits on is a lie, a page that cannot be is the truth. The Card and Alert samples get a page-rung block. The modal stand-in gets the page under colors.overlay, painted as a background-image because dialogReplica needs that box to stay the containing block for its close button. The rule this leaves, now in AGENTS.md: a sample that is a rung needs a different rung under it. The badge section already did this with three explicit strips and it was read as a badge-specific flourish rather than the general case. No test sees any of it — jsdom applies none of StyleX's CSS — so it is read off the board, which is where all three of these were found. Two things left alone and recorded in the note: the card-to-page step in the dark palette is six values of 255, which reads because the shadow carries most of the work and moving it means moving elevatedBackground; and the board's own panel still sits on the board's page fill with only its shadow between them, which is the relationship a card has to a page and is what the panel is. Model: claude-opus-5[1m] Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(ui): wait for the list to take the keyboard, not for two frames select.test.tsx's "End walks to the last row" failed on CI and passed on the commit before it, on a change that touched only the gallery and two documents. It is not a regression and not the component. dom.tsx settles every interaction with a fixed two animation frames, which is enough on an idle machine and is otherwise the scheduler luck the root testing rule forbids by name. Base UI schedules part of a popup's state on its own animation frame, so how many frames an interaction needs is a property of the machine that ran it: cutting the budget to one frame fails a different test in the same file than two does, which is how the fragility was measured rather than guessed. Every keyboard test in that file presses a key straight after opening the list, and the failing one was the only one that did not first state the precondition the others assert. A probe showed why that matters: moving focus off a row clears every data-highlighted mark, so a key arriving a beat early finds a list with no active row and does nothing — which is the false CI reported. dom.tsx gains until(ready, what): a bounded wait on an explicit condition that throws with what it was waiting for, so a real regression still fails the run and says why. The test states its precondition and both of its effects through it. Verified with the frame budget cut to one, which is the stress proxy: before, the select file failed there; after, all 244 pass. At zero frames the menu suite fails wholesale, which is not load but a popup never opening — the proxy has a floor, and one frame is it. Model: claude-opus-5[1m] Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Board check (static Storybook, Chromium via Playwright), light and dark palettes:
One finding about the harness rather than this PR: the static Storybook build does not link the StyleX stylesheet ( 🤖 Generated with Claude Code |
|
Sidebar toggle reworked after review: the collapsed state no longer pastes a chevron in the content area. The open icon has a wider panel with three rows; collapsing slides the divider 10 → 6.5 and the rows shrink into the rail as dots (transform only), so nothing is added for a state and the static 🤖 Generated with Claude Code |
a112987 to
a393ade
Compare
* feat(ui): add the @lody/ui Toggle, ToggleGroup and Toolbar A control that stays pressed, a set of them answering to one value, and the bar that holds them, sharing one `toggle` token group. On is the well rather than ink. The rules give a stored state ink, and ink is what a control that already sits in a well becomes when it is on — a Switch's off state occupies the well, so on has to leave it. A toggle rests on nothing, so the well is free, and sinking into it is the plainest thing this system can say about a button that went down and stayed. It also keeps a bar of eight from reading as eight primary buttons. A set is not a Tabs strip with its track removed: a strip picks what you see, so it is one control with one pill sliding across it, while a set stores what is on and two of its members can be pressed at once. A bar draws nothing at all — not even the line a table draws — and exists to make a row of controls one tab stop the arrow keys walk. Migrates the task body's format bar and the tasks view's "Show" chips, and deletes the dead Radix `toggle.tsx` and its dependency. Model: claude-opus-5[1m] * docs(ui): link the toggle family note to its PR Model: claude-opus-5[1m]
a393ade to
8c0590b
Compare
An original 75-icon set drawn in the package on one 24 grid at a 1.5 stroke, following Nucleo's drawing conventions and none of its paths. `src/icons/registry.ts` holds each drawing as outline marks plus, where it has depth, a layer model (mass, mid, front, detail, outer, dots); `Icon` renders it in four treatments — outline, duotone, glyph and bulk — from those same paths, with a glyph's cuts made through a mask so they are holes rather than a painted panel colour. Nine stateful icons move between two states by one registered custom property, `--lody-icon-t`, that CSS transitions: transform, opacity and dash offset only, no path morphing, so at rest each state is the static drawing. Board section, tests, README row, AGENTS.md rule, spec section and a proposed Agent Note. No product caller migrates yet. Model: claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The draft-02 design board (`index.html`) the icon registry was generated from, and a proposed note from a parallel exploration of a wider icon family. Committed as found in the worktree so the exploration travels with the PR; neither is consumed by the package. Model: claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Model: claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`index.html` was not the board the registry was generated from, as the previous commit claimed: it was a 42-symbol snapshot of the draft in progress (33 icons plus the model candidates), copied to the worktree root by a parallel session. The registry was generated from the finished 75-icon board, which `test/icons.test.tsx` and the Agent Note describe; the board itself is a design artifact and stays out of the tree. Model: claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…hevron The collapsed state of the sidebar toggle added a chevron in the content area, which is a symbol about the sidebar rather than the sidebar. Now nothing is added for a state: the open icon has a wider panel with three rows, and collapsing slides the divider from 10 to 6.5 while the rows shrink into the rail as dots — a narrow sidebar showing only its marks. The static `sidebar` and `sidebar-collapsed` icons are the two ends of the same drawing. Model: claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The set landed as four more rows on the token board, which is reached through `packages/components`' Storybook. That was wrong twice. The board's question is "what is this token's value", answered once per token off the rendered node; an icon has no token, and its question is asked 75 times — find the drawing, put it at the size the surface uses, in that surface's colour, on the rung it will sit on, and take it away as markup. Those are controls, not samples. And the set is `@lody/ui`'s, while Storybook belongs to a package that only consumes it: looking at an icon should not require building the product's component library. So `packages/ui/playground` is a Vite page of its own, `pnpm --filter @lody/ui playground`, with no Storybook under it: search, a size slider from 12 to 64, the four treatments, the semantic tones, the four rungs — the accent rung is where a glyph's mask shows, since the cut is a hole and the rung shows through — a per-icon panel with the drawing enlarged on the 24 grid and `copy svg`, and the stateful icons under a slow-motion switch that lets `--lody-icon-t` be watched rather than inferred from its two ends. `src/gallery/gallery.tsx` goes back to what it was. Nothing in `playground/` is exported and the package still depends on React, Base UI and StyleX alone at runtime; `vite` and `@vitejs/plugin-react` join its devDependencies. `playground.css` carries the page's own reset and the slow-motion override, and also gives the StyleX injection target a chunk to exist in: with no CSS of its own the bundle emits no `index-<hash>.css` and the built page comes out unstyled. Typecheck, the production build and the dev server were run. The page itself has not been opened in a browser; the note says so. Model: claude-opus-5[1m]
…arks The collapsed state put its divider at 6.5, which leaves a 3-unit rail. The two strokes bounding that rail take 1.5 of it, so the marks inside had 1.5 units to live in and were drawn 2 wide: they overlapped the frame on one side and the divider on the other, and the icon read as three lumps. The playground is what showed it. The animated state was wrong a second way. It shrank the rows with `scaleX`, and a horizontal scale leaves a stroke's thickness alone while squashing its round caps, so at t = 1 the marks were 0.8 wide against the static drawing's 2. The two ends of the transition were two different drawings, which is the one thing a stateful icon here may not be. The divider now collapses to 8.5: the rail is 5 units and the 3.5 between its strokes holds one mark of 2 with 0.75 of air either side. The rows retract by their dash instead of scaling — a dash shortened to zero length is a round cap and nothing else, which is a dot — and the uniform scale carrying it from 1.5 to 2 grows the stroke without touching its shape. Two marks, not three: at 2 wide a third leaves a 1-unit gap, and a size ladder shows three smearing into one vertical stroke by 20px while two stay separate. The middle row fades; the outer two land on (6, 9.5) and (6, 14.5), which is what `sidebar-collapsed` draws. Checked in the playground in Chromium. The animated t = 1 and the static drawing were screenshotted at 64px and compared pixel by pixel: 825 ink pixels against 833, differing only by a one-pixel fringe in one direction on every stroke, which is a sub-pixel crop offset and not a difference in shape. The transition was rendered at t = 0, 0.25, 0.5, 0.75 and 1 — the caps stay round throughout and no sliver appears. The note records the correction and retires the `translateX(-3px)` measurement, which was read off the broken drawing. Model: claude-opus-5[1m]
Drawn with an 11-wide dome over 6 units of straight side the bell was 14 across and 13 down: a tube with a cap on it, not a bell. The dome is 12 now and the side sweeps out to a mouth 16.4 across a 12.7-tall body. The mouth is the part worth reading. Widening it while it was still a flange — a straight flare from the side down to the mouth line — made the filled variants grow horns: the flare met the mouth at 39 degrees, and a 39-degree tip is a spike that `stroke-linejoin: round` hides in the outline and a fill cannot. Rounding the spike is not available either, since a 0.8 radius at that angle consumes 2.28 units of a 3.2-unit flare and there is no flange left. So the flange is gone. The side arrives at the mouth vertically through a curve, turns through a 0.7 corner, and the mouth is a line under it — a 90-degree corner, which a fill can hold. That is the general form of a rule the set already had for glyphs: what the outline's stroke rounds, the mass has to round itself. The clapper's arc is a segment rather than a half-circle, so it hangs a unit clear of the mouth and stops at the live area instead of 1.25 past it; `front` is that same silhouette filled, which is what gives bulk a solid clapper under the 35% body. A wider bell swings wider. The mouth is 16 units from the nub it pivots on, and at the old 14 degrees the corner furthest from the pivot put its stroke 0.03 from the canvas — inside, but not a margin. The swing is 12 degrees, which leaves 0.42 and reads the same. `BellRingIcon` no longer restates the path: it reads `ICONS.bell`, and a test holds it there. That is the guard the sidebar did not have, and both of this branch's drawing bugs were a stateful icon and a static one drifting apart. Seen in the playground in all four treatments at 12 through 48, and the swing rendered at -12, -6, 0, 6 and 12 degrees against the canvas edge. `pnpm --filter @lody/ui test` passes, 262 tests. Model: claude-opus-5[1m]
`packages/components` imports 239 distinct icons from `lucide-react` across 295 files, 2138 usages. Collapsed onto the drawings that would serve them, the set covered 78% of that. These sixteen take it to 91%: monitor, alert-circle, users, circle, shield-alert, folder-plus, arrow-up, mail, wrench, undo, quote, save, image, pin-off, fork and pull-request-closed. `monitor` alone answers 52 usages, which is what a product about machines looks like from the icon layer. Each takes its family's skeleton before it takes a better idea: `monitor` and `image` the 2px container `sidebar` and `terminal` already share, `alert-circle` the circle family's ring with `info-circle` turned over, `shield-alert` the shield, `users` `user` twice, `fork` and `pull-request-closed` the git family's two node columns and 2px nodes. `send` and `arrow-up`, and `pin` and `pin-off`, are one drawing under two names: the path is a module const rather than two copies, which is the lesson of the two drift bugs earlier on this branch. `package` was dropped from the batch rather than drawn — a box with a lid is `archive` and an isometric one is `model`. Three were redrawn after the first pass, all failing the same way, reading at 120px and not at 20. `quote` as a block with the tail notched out of a corner is a pair of counters by 20px; it is a hook now. `pin-off` broken into fragments is not a pin, so the pin stays whole and takes the line across it. `pull-request-closed` had its cross floating clear of the branch it closes. `folder-plus` and `users` ended up with no layer model, which is a rule worth having: **a drawing a fill would cost its meaning takes no layers.** A cross on a 100% front panel is currentColor on currentColor and what is left is `folder`; half a person as a stroke beside a filled one is two fragments off a silhouette. `folder-open` and `user` were already outline-only for the same reason. The playground's bulk column is where both showed. The icon rules move to `src/icons/AGENTS.md`: the parent was 33 bytes under its gate and these are now a scope of their own, with the grid, the four treatments, the two-state rules and what to check before calling a drawing done. Families are reordered so members line up in a list, which is what that file asks for. Seen in the playground at 32 and 52 in outline, glyph and bulk. `pnpm --filter @lody/ui test` passes, 262 tests. Model: claude-opus-5[1m]
A second reading of the board sent six back, and the reasons divide into three kinds. **A shape that is not the thing.** `quote`, twice: a notched block reads as a counter, and a ball with its tail swept too far round it reads as a lowercase e. What a quote mark is is a ball with a short tail, so that is what it is now. **A proportion that is not the thing's.** `save` at 17 by 15 is a letterbox; a disk is square, so it is 15 by 15, and its shutter and label centre on 12. `code` had 4.5 of chevron travel and a slash 4 wide, which bunched its mass in the middle and read narrow beside everything on its row; the chevrons reach the live area now and the slash leans across it. **A part that does not belong to the whole.** `folder-plus` carried the front-panel line as well as the cross, and that line splits the face the cross has to sit in — the line is gone and the cross centres on the face. `pull-request-closed` drew its cross at an arrowhead's size with the branch stopping two units short of it, so the cross and the branch read as two drawings sharing a column; the cross is a node's size now and the branch runs up to meet it. And `fork` put its heads on 6.5 and 17.5 while `branch`, `merge` and `pull-request` put theirs on 7 and 17 — symmetrical on its own, out of line the moment the family is read down a list, which is the only way a family is ever read. Four of the six read fine enlarged and failed at 20, which is the size they will be used at. No test sees any of this. Seen in the playground: all six at 112px on the grid, the git family side by side against its 7 and 17 columns, and `save` in all four treatments at 16 through 32. `pnpm --filter @lody/ui test` passes, 262 tests. Model: claude-opus-5[1m]
It was substituted into the batch when `package` turned out to be `archive`, and it went through four drawings without arriving: a cross capping the branch, which leaves two columns with nothing between them and reads as two lollipops rather than a pull request; the branch run up into the cross to close that gap, which came out a spear; `pull-request`'s arm restored with a cross for its arrowhead, drawn at the size that fit the room left above the arm rather than the size a cross is in this set; and the same again at `x-circle`'s 5. The last was defensible and still not good enough to keep. The lesson is about the batch rather than the drawing. It answers four usages; everything else in the fifteen answers seven or more, and none of them needed more than one redraw. A drawing that has to say a relation did not happen — not a thing, not a state — is a harder problem than four usages justify, and the honest move when `package` fell out was to draw fifteen, not to fill the slot from the ranked list because it was open. `packages/components` keeps `lucide-react`'s `GitPullRequestClosed` at those four sites, as it does for every icon this set has not taken over; no caller changes. 90 icons. Model: claude-opus-5[1m]
8c0590b to
352d51f
Compare
The gallery file was missing Toggle component definitions and rows during the rebase. Restored from origin/feat/ui-v2 to include all necessary Toggle, ToggleGroup, and Toolbar components and their gallery representations. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Model: gpt-5
11fc7a0 to
507a751
Compare
Related issue
Same-repository branch; no intake Issue.
Problem / pressure
Three sources of icons and no shared grid:
packages/ui/src/internal/glyphs.tsxdraws nine glyphs on a 16 grid at a 1.6 stroke because the package may depend on nothing but React, Base UI and StyleX;packages/componentsusesreact-iconsat sixteen sites (almost all brand marks) and inlines an<svg>in roughly four hundred files, each to whatever grid its author had. A menu row chevron and the sidebar toggle are two weights of two strokes on two grids, and every new surface draws a third. The product wants a set of its own, with the intermediate treatments (duotone and bulk) and with state: a sidebar that shows what is in it and whether it is open, animated between the two.Summary
packages/ui/src/icons/registry.ts: 75 original icons on one 24 grid (20 live area, 1.5 stroke, round caps and joins, 2px container corners, 2px git nodes). Follows Nucleo drawing conventions, copies none of its paths. Each icon ismarks(outline) plus, where it has depth,layers(mass / mid / front / detail / outer / dots).icon.tsx:<Icon name variant title>andcreateIcon. Four treatments from the same paths: outline, duotone (mass at 18%), glyph (filled, marks cut through an SVG mask so the cut is a hole and not a painted panel colour), bulk (no stroke; mass 35%, mid 55%, front 100%). An icon states no size and no colour: it fills its box and inheritscurrentColor, the contract the existing glyphs have. Decoration unlesstitleis given.stateful.tsx: nine two-state icons (sidebar, chevron, check, eye, play/pause, star, bell, refresh, folder) driven by one registered custom property--lody-icon-tthat CSS transitions; only transform, opacity and dash offset move, no path morphing;prefers-reduced-motionzeroes the duration.index.ts: 75 named components (SidebarIcon,BranchIcon, ...), exported as@lody/ui/icons.test/icons.test.tsx, README row,AGENTS.mdrule (existing bullets trimmed in wording only to stay under the 8 KiB gate),specs/ui-primitives.mdIcons section (draft), proposed Agent Note.Visual explanation
Before / after
@lody/ui,react-iconsand ~400 ad hoc inline SVGs in components@lody/ui/icons, four treatments per drawing, nine animated two-state iconsTest plan
pnpm --filter @lody/ui typecheck: clean.pnpm --filter @lody/ui test: 21 files, 261 tests pass (17 new intest/icons.test.tsx: registry shape and grid bounds, a11y contract, the four treatments, mask uniqueness and name-in-id, outer strokes surviving a glyph, bulk without outline, two states as one markup with one number changed).oxlinton the new and changed files: 0 errors.pnpm run docs check: only the four pre-existingacp-extension-dshbroken links remain.--lody-icon-ttransition interpolates live (see the PR comment for the computed values). jsdom applies no CSS, so tests cover only the two ends.check:public-boundaryfailed on uninitialised submodules in this worktree, unrelated to this diff.Context handoff
Instructions for reviewing agents
icon.tsxglyph mask (id fromuseIdplus the icon name; strokes outside the mass drawn outside the mask), andstateful.tsx(CSS.registerPropertyguard, StyleX values usingvar(--lody-icon-t), reduced-motion media query).sinof the same number rather than a keyframe; theAGENTS.mdtrims must not have changed any rule.sin()incalc()and registered-property transitions need Chromium, Safari 16.4 or Firefox 128; the 16px rendering is the 24 grid scaled, not an optically corrected small size.Authoring context
@lody/uidepends only on React, Base UI and StyleX; no Nucleo paths (paid library, no files in hand, redistribution understood to be forbidden); no product caller migrates;glyphs.tsxuntouched.@lody/ui/icons; a module-levelCSS.registerPropertycall guarded for SSR and repeat registration.Spinnerfromrefresh.🤖 Generated with Claude Code